iT邦幫忙

第 12 屆 iThome 鐵人賽

DAY 19
1
Modern Web

JavaScript 之旅系列 第 19

JavaScript 之旅 (19):String.prototype.matchAll()

  • 分享至 

  • xImage
  •  

本篇介紹 ES2020 (ES11) 提供的 String.prototype.matchAll()

過去的 RegExp

若將一個字串使用的 RegExp (regular expression,正規表達式,正規表示式) 設定了 stickyglobal flag,則可能會有多個 capture groups,常見的情境會想迭代所有 match 到的結果,可能會有幾種作法:

  • String.prototype.match()
  • RegExp.prototype.exec()
  • String.prototype.replace()

分別來介紹過去的這些作法有哪些缺點。

String.prototype.match()

若在 String.prototype.match() 使用的 RegExp 沒有設定 global flag,就只能取得第一個 capture group、indexinputgroups 這些資訊:

let string = 'JavaScript ES7 ES8 ES9 ECMAScript';
let pattern = /(ES(\d+))/;

let result = string.match(pattern);
console.log(result);
// ["ES7", "ES7", "7", index: 0, input: "ES7 ES8 ES9 ECMAScript", groups: undefined]

若有 global flag 不是取得所有 capture group 和其他資訊,而是只能取得所有 match 到的字串:

let string = 'ES7 ES8 ES9 ECMAScript';
let pattern = /(ES(\d+))/g;

let result = string.match(pattern);
console.log(result);
// ["ES7", "ES8", "ES9"]

這樣根本不夠用!這就是 String.prototype.match() 可惜的地方。

如果你只想取得所有 match 到的字串,那 String.prototype.match() 很好用,若你想要的是詳細一點的資訊,例如:capture group,String.prototype.match() 是無法滿足你的。

那改用 RegExp.prototype.exec() 呢?

RegExp.prototype.exec()

若在 RegExp.prototype.exec() 使用的 RegExp 有設定 globalsticky flag,在執行 RegExp.prototype.exec() 後,會在該 RegExp 物件儲存前一個 match 的 lastIndex (即上次最後 match 的字串的最後一個字元在原字串中的 index 為何,用於下一次 match 開始的 index)。

所以只要重複執行幾次 RegExp.prototype.exec(),就能一直取得 match 的結果,即取得第一個 capture group、indexinputgroups 這些資訊。

直到 match 的結果為 null 時,代表已經找不到 match 的字串,此時會將 RegExp 物件的 lastIndex 設為 0,代表之後執行 RegExp.prototype.exec() 會重頭開始 match 字串。

用剛剛的範例來舉例:match 到幾個字串就要跑幾次,每次都會更新 RegExp 物件的 lastIndex

let string = 'ES7 ES8 ES9 ECMAScript';
let pattern = /(ES(\d+))/g;

console.log(pattern.lastIndex);
// 0

console.log(pattern.exec(string));
// ["ES7", "ES7", "7", index: 0, input: "ES7 ES8 ES9 ECMAScript", groups: undefined]
console.log(pattern.lastIndex);
// 3

console.log(pattern.exec(string));
// ["ES8", "ES8", "8", index: 4, input: "ES7 ES8 ES9 ECMAScript", groups: undefined]
console.log(pattern.lastIndex);
// 7

console.log(pattern.exec(string));
// ["ES9", "ES9", "9", index: 8, input: "ES7 ES8 ES9 ECMAScript", groups: undefined]
console.log(pattern.lastIndex);
// 11

console.log(pattern.exec(string));
// null
console.log(pattern.lastIndex);
// 0

RegExp.prototype.exec() 回傳為 null,且 RegExp 物件的 lastIndex0 時,你再次執行 RegExp.prototype.exec() 就會重頭開始 match 字串:

console.log(pattern.exec(string));
// ["ES7", "ES7", "7", index: 0, input: "ES7 ES8 ES9 ECMAScript", groups: undefined]
console.log(pattern.lastIndex);
// 3

看到上面手動一步一步執行 RegExp.prototype.exec() 感到累嗎?用迴圈改寫一下:

let string = 'ES7 ES8 ES9 ECMAScript';
let pattern = /(ES(\d+))/g;
let match;

while (match = pattern.exec(string)) {
  console.log(match);
}
// ["ES7", "ES7", "7", index: 0, input: "ES7 ES8 ES9 ECMAScript", groups: undefined]
// ["ES8", "ES8", "8", index: 4, input: "ES7 ES8 ES9 ECMAScript", groups: undefined]
// ["ES9", "ES9", "9", index: 8, input: "ES7 ES8 ES9 ECMAScript", groups: undefined]

舒服多了!

若要保存每次執行 RegExp.prototype.exec() 回傳的 match 結果,可能會這樣寫:

let string = 'ES7 ES8 ES9 ECMAScript';
let pattern = /(ES(\d+))/g;
let match;
let matches = [];

while (match = pattern.exec(string)) {
  matches.push(match);
}

console.log(matches);
// [
//   ["ES7", "ES7", "7", index: 0, input: "ES7 ES8 ES9 ECMAScript", groups: undefined],
//   ["ES8", "ES8", "8", index: 4, input: "ES7 ES8 ES9 ECMAScript", groups: undefined],
//   ["ES9", "ES9", "9", index: 8, input: "ES7 ES8 ES9 ECMAScript", groups: undefined]
// ]

到這邊你覺得 RegExp.prototype.exec() 還行嗎?其實有一些小缺點!

RegExp.prototype.exec() 的小缺點

小缺點如下:

  1. 儲存的 match 結果變數是多餘的
  2. 執行 RegExp.prototype.exec() 會改變 RegExp 物件的 lastIndex

先來說第一個小缺點:為了取得每次 RegExp.prototype.exec() 的 match 結果,且因需要設定迴圈的中止條件,要將 match 結果存在一個變數,這個變數宣告是為了 RegExp.prototype.exec() 的行為而建立的變數 (即下面的 match 變數):

let string = 'ES7 ES8 ES9 ECMAScript';
let pattern = /(ES(\d+))/g;
let match;

while (match = pattern.exec(string)) {
	console.log(match);
}

逼不得已啊...。那能改用 for-of 嗎?這樣就不會多宣告變數啦!

RegExp.prototype.exec() 是不可能做到的,而本篇要介紹的 String.prototype.matchAll() 就能解決這個問題,後面會提到。

接著來說第二個小缺點:執行 RegExp.prototype.exec() 會改變 RegExp 物件的 lastIndex

這看似沒什麼問題啊?其實問題會發生在不懂 RegExp.prototype.exec() 的人。

假設 RegExp pattern 是共用的,需要讓很多字串 match (此範例為 string1string2 ):

  • 第一次對 string1 使用 RegExp.prototype.exec()
  • 第二次對 string2 使用 RegExp.prototype.exec()
let string1 = 'ES7 ES8 ES9 ECMAScript';
let string2 = 'ES10 ES11 ECMAScript';
let pattern = /(ES(\d+))/g;

console.log(pattern.lastIndex);
// 0

console.log(pattern.exec(string1));
// ["ES7", "ES7", "7", index: 0, input: "ES7 ES8 ES9 ECMAScript", groups: undefined]
console.log(pattern.lastIndex);
// 3

console.log(pattern.exec(string2));
// ["ES11", "ES11", "11", index: 5, input: "ES10 ES11 ECMAScript", groups: undefined]
console.log(pattern.lastIndex);
// 9

發現問題了嗎?不同字串共用的 RegExp pattern 會因為被修改了 lastIndex,而造成拿到的結果不符合你的預期,string2 看起來是第一次用該 RegExp pattern 來 match 字串,但卻拿到第二次才會被 match 的字串 (即 ES11,原本以為會拿到 ES10 )。

如果你熟悉 RegExp.prototype.exec(),這個問題根本不會發生,但過去還是新手的我就採過這個雷 XD

String.prototype.replace()

有些情境需要透過 String.prototype.replace() 和 RegExp 來將某些字串取代成其他內容。

例如:將 ES7 轉成 ES2016,ES8 轉成 ES2017,ES9 轉成 ES2018,只有前綴 ES 後面加上數字字元才能轉換:

let string = 'ES7 ES8 ES9 ECMAScript';
let pattern = /(ES(\d+))/g;

let newString = string.replace(pattern, function(matched, position1, position2) {
  const version = position2;
  return `ES${2009 + Number(version)}`;
});

console.log(newString);
// ES2016 ES2017 ES2018 ECMAScript

註:String.prototype.replace() 的第二個參數可以是字串,或是 callback function,而 callback function 會有多個參數,包括:matched (match 到的字串)、positionN (第幾個 capture group)、index (match 到字元的 index) 和 input (正在 match 的整個字串)。

string.replace(pattern, function(matched, position1, ...positionN, index, input) {
  // ...
});

若要讓 String.prototype.replace() 的行為很像 RegExp.prototype.exec() 回傳的 match 結果,可以這樣寫:

let string = 'ES7 ES8 ES9 ECMAScript';
let pattern = /(ES(\d+))/g;
let matches = [];

let newString = string.replace(pattern, function() {
  const match = [...arguments].slice(0, -2);
  match.input = arguments[arguments.length - 1];
  match.index = arguments[arguments.length - 2];
  matches.push(match);

  return `ES${2009 + Number(match[2])}`;
});

console.log(newString);
// ES2016 ES2017 ES2018 ECMAScript

console.log(matches);
// [
//   ["ES7", "ES7", "7", input: "ES7 ES8 ES9 ECMAScript", index: 0],
//   ["ES8", "ES8", "8", input: "ES7 ES8 ES9 ECMAScript", index: 4],
//   ["ES9", "ES9", "9", input: "ES7 ES8 ES9 ECMAScript", index: 8]
// ]

但太麻煩了...

現代的 String.prototype.matchAll()

let string = 'ES7 ES8 ES9 ECMAScript';
let pattern = /(ES(\d+))/g;

let matches = string.matchAll(pattern);
console.log(matches);
// RegExpStringIterator {}

for (const match of matches) {
  console.log(match);
  console.log(`lastIndex: ${pattern.lastIndex}`);
}
// ["ES7", "ES7", "7", index: 0, input: "ES7 ES8 ES9 ECMAScript", groups: undefined]
// lastIndex: 0
// ["ES8", "ES8", "8", index: 4, input: "ES7 ES8 ES9 ECMAScript", groups: undefined]
// lastIndex: 0
// ["ES9", "ES9", "9", index: 8, input: "ES7 ES8 ES9 ECMAScript", groups: undefined]
// lastIndex: 0
let string = 'ES7 ES8 ES9 ECMAScript';
let pattern = /(ES(\d+))/g;

let matches = [...string.matchAll(pattern)];
console.log(matches);
// [
//   ["ES7", "ES7", "7", index: 0, input: "ES7 ES8 ES9 ECMAScript", groups: undefined],
//   ["ES8", "ES8", "8", index: 4, input: "ES7 ES8 ES9 ECMAScript", groups: undefined],
//   ["ES9", "ES9", "9", index: 8, input: "ES7 ES8 ES9 ECMAScript", groups: undefined]
// ]

資料來源


上一篇
JavaScript 之旅 (18):Array method - flat & flatMap
下一篇
JavaScript 之旅 (20):Promise.allSettled()
系列文
JavaScript 之旅30
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言